@nbtca/prompt 1.4.2 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +27 -58
  2. package/SECURITY.md +16 -45
  3. package/dist/app/app.js +167 -64
  4. package/dist/app/chrome.js +67 -50
  5. package/dist/app/fields/list-field.js +12 -25
  6. package/dist/app/fields/text-field.js +3 -8
  7. package/dist/app/frame.js +16 -23
  8. package/dist/app/keys.js +110 -2
  9. package/dist/app/views/docs-render.js +34 -26
  10. package/dist/app/views/docs.js +280 -68
  11. package/dist/app/views/events-render.js +21 -27
  12. package/dist/app/views/events.js +57 -33
  13. package/dist/app/views/home.js +67 -47
  14. package/dist/app/views/schedule-grid-cursor.js +9 -18
  15. package/dist/app/views/schedule-render.js +51 -74
  16. package/dist/app/views/schedule.js +246 -101
  17. package/dist/app/views/settings-render.js +8 -19
  18. package/dist/app/views/settings.js +92 -17
  19. package/dist/auth/cookie-transport.js +31 -32
  20. package/dist/auth/errors.js +3 -1
  21. package/dist/auth/nbt-auth.js +42 -25
  22. package/dist/auth/session-store.js +17 -9
  23. package/dist/cli.js +570 -0
  24. package/dist/config/data.js +9 -11
  25. package/dist/config/preferences.js +21 -7
  26. package/dist/core/calendar-day.js +37 -0
  27. package/dist/core/canvas.js +1 -0
  28. package/dist/core/capabilities.js +6 -3
  29. package/dist/core/components/confirm.js +9 -8
  30. package/dist/core/components/menu.js +64 -38
  31. package/dist/core/components/messages.js +12 -4
  32. package/dist/core/components/painter.js +3 -1
  33. package/dist/core/components/spinner.js +34 -7
  34. package/dist/core/components/text-input.js +24 -18
  35. package/dist/core/icons.js +2 -2
  36. package/dist/core/logo.js +23 -5
  37. package/dist/core/motion.js +25 -19
  38. package/dist/core/text.js +186 -69
  39. package/dist/core/theme.js +0 -28
  40. package/dist/core/transitions.js +2 -2
  41. package/dist/core/ui.js +15 -13
  42. package/dist/core/vim-keys.js +156 -19
  43. package/dist/features/about.js +23 -0
  44. package/dist/features/calendar-heatmap.js +16 -40
  45. package/dist/features/calendar-query.js +1 -2
  46. package/dist/features/calendar-store.js +27 -0
  47. package/dist/features/calendar.js +66 -190
  48. package/dist/features/docs-client.js +225 -0
  49. package/dist/features/docs.js +615 -298
  50. package/dist/features/links.js +44 -29
  51. package/dist/features/schedule-render.js +65 -101
  52. package/dist/features/schedule-store.js +51 -9
  53. package/dist/features/schedule-view.js +46 -213
  54. package/dist/features/status.js +117 -60
  55. package/dist/features/student-timetable.js +74 -97
  56. package/dist/features/theme.js +9 -5
  57. package/dist/features/timetable-sanitize.js +40 -0
  58. package/dist/features/update.js +12 -29
  59. package/dist/i18n/index.js +83 -19
  60. package/dist/i18n/locales/en.json +8 -3
  61. package/dist/i18n/locales/zh.json +8 -3
  62. package/dist/index.js +6 -474
  63. package/dist/logo/ca-dotmatrix.txt +16 -18
  64. package/dist/main.js +7 -48
  65. package/package.json +28 -18
  66. package/bin/nbtca-welcome.js +0 -2
  67. package/dist/core/components/screen.js +0 -18
  68. package/dist/core/menu.js +0 -68
  69. package/dist/features/schedule-query.js +0 -47
  70. package/dist/features/settings.js +0 -127
  71. package/dist/logo/ca-logo.png +0 -0
package/README.md CHANGED
@@ -1,89 +1,58 @@
1
1
  # NBTCA Prompt
2
2
 
3
- Terminal-based information system for the NBTCA community.
3
+ Terminal client for NBTCA events, documentation, status and personal timetables.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@nbtca/prompt)](https://www.npmjs.com/package/@nbtca/prompt)
6
6
  [![License](https://img.shields.io/npm/l/@nbtca/prompt)](LICENSE)
7
7
 
8
+ ## Requirements
9
+
10
+ - Node.js 20.12 or newer
11
+
8
12
  ## Install
9
13
 
10
14
  ```bash
11
- npm install -g @nbtca/prompt
15
+ npm install --global @nbtca/prompt
16
+ nbtca
12
17
  ```
13
18
 
14
- Or run directly:
19
+ Run a command without installing:
15
20
 
16
21
  ```bash
17
- npx @nbtca/prompt
22
+ npx @nbtca/prompt --help
18
23
  ```
19
24
 
20
- ## Personal timetable
25
+ ## Commands
21
26
 
22
- Prompt owns the school login and local session; `@nbtca/nbtcal` owns timetable
23
- normalization and ICS generation.
24
-
25
- For a persistent local session:
26
-
27
- ```bash
27
+ ```text
28
+ nbtca events [--json]
29
+ nbtca docs
30
+ nbtca status
28
31
  nbtca schedule login
29
32
  nbtca schedule terms
30
33
  nbtca schedule export --term=2026:3 --week-one=YYYY-MM-DD
31
34
  nbtca schedule logout
32
35
  ```
33
36
 
34
- For a single run that neither reads nor saves a session (`--no-save` is an
35
- alias):
37
+ Use `--plain` for stable output without color. `schedule export --one-shot`
38
+ avoids reading or saving a campus session.
39
+
40
+ Prompt never stores passwords. Persisted sessions contain only a masked account
41
+ hint and a CookieJar, protected with user-only permissions on POSIX systems.
42
+ Treat exported calendars and session files as private data. See [SECURITY.md](SECURITY.md).
43
+
44
+ ## Development
36
45
 
37
46
  ```bash
38
- npx @nbtca/prompt schedule export --one-shot
47
+ npm ci
48
+ npm run check
39
49
  ```
40
50
 
41
- Both the student id and password are masked in the terminal. The password is
42
- never saved. A persistent login stores only a masked account hint and the
43
- CookieJar in the user's state directory:
44
-
45
- - Unix/macOS default: `~/.local/state/nbtca/session.json`
46
- - Windows default: `%LOCALAPPDATA%/nbtca/session.json`
47
- - `$XDG_STATE_HOME` is honored when it is an absolute path
48
-
49
- On POSIX systems the directory is `0700` and the file is `0600`. `npx`, a local
50
- installation and a global installation therefore share the same state without
51
- depending on npm's disposable package cache. The session is a bearer secret;
52
- use `--one-shot` on shared machines and `schedule logout` when finished.
53
- Saved sessions use a sliding seven-day local expiry and are cleared immediately
54
- when the school reports that they have expired.
55
-
56
- JWXT currently returns week numbers and period times but not the first calendar
57
- date of a term. When no authoritative date map is available, Prompt asks for
58
- the first teaching Monday or accepts `--week-one=YYYY-MM-DD`. Confirm it against
59
- the official school calendar; Prompt will not guess. A slider or other browser
60
- challenge is also never bypassed—the CLI stops with an actionable message.
61
- Without an authoritative date map, the result is a base teaching-week schedule:
62
- holidays, make-up classes and temporary changes still require school notices.
63
-
64
- ## Documentation
65
-
66
- Project documentation has been moved to the GitHub Wiki.
67
-
68
- - [Home](https://github.com/nbtca/Prompt/wiki)
69
- - [Getting Started](https://github.com/nbtca/Prompt/wiki/Getting-Started)
70
- - [Development Guide](https://github.com/nbtca/Prompt/wiki/Development-Guide)
71
- - [Terminal UX](https://github.com/nbtca/Prompt/wiki/Terminal-UX)
72
- - [Release Notes](https://github.com/nbtca/Prompt/wiki/Release-Notes)
73
- - [Features](https://github.com/nbtca/Prompt/wiki/Features)
74
- - [Terminal Compatibility](https://github.com/nbtca/Prompt/wiki/Terminal-Compatibility)
75
- - [FAQ](https://github.com/nbtca/Prompt/wiki/FAQ)
76
-
77
- ## Requirements
51
+ `check` runs formatting, lint, full TypeScript validation, tests, build, package
52
+ consumer checks and dependency audit.
78
53
 
79
- - Node.js >= 20.12.0
54
+ Project guides and release notes live in the [Wiki](https://github.com/nbtca/Prompt/wiki).
80
55
 
81
56
  ## License
82
57
 
83
58
  MIT
84
-
85
- ## Links
86
-
87
- - Website: https://nbtca.space
88
- - GitHub: https://github.com/nbtca
89
- - NPM: https://www.npmjs.com/package/@nbtca/prompt
package/SECURITY.md CHANGED
@@ -1,47 +1,18 @@
1
1
  # Security Policy
2
2
 
3
- ## School credentials
4
-
5
- Prompt sends the student id and an in-memory encrypted password only to the
6
- school's WebVPN/CAS login flow. It does not offer `--password`, password
7
- environment variables or credential files. Password and verification input is
8
- masked and is never written to preferences, state, cache, ICS or logs.
9
-
10
- If the school requests a slider, OTP, FIDO or another browser challenge, Prompt
11
- fails closed. It does not automate or bypass the challenge.
12
-
13
- ## Authenticated transport
14
-
15
- Authentication redirects are restricted to exact HTTPS hosts and routes for
16
- the campus WebVPN, authentication service and JWXT. CAS `service` and
17
- WebVPN `origin` parameters are validated against explicit callback routes.
18
- Caller-supplied `Cookie`, `Authorization` and `Host` headers are rejected.
19
-
20
- Errors expose only stable local codes and stages. Response bodies, redirect
21
- queries, cookies, encrypted passwords and underlying network error objects are
22
- not included in user-facing output.
23
-
24
- ## Persisted session
25
-
26
- The optional persisted CookieJar is a bearer secret. It is stored under the
27
- user's state directory with a versioned schema, an atomic write, a `0700`
28
- directory and a `0600` file on POSIX systems. The saved account hint is masked,
29
- and Prompt never adds the full student id or password to the state schema. The
30
- opaque school cookies must still be treated as bearer secrets. Sessions have a
31
- sliding seven-day local expiry.
32
-
33
- Use `nbtca schedule logout` to clear it. Use `--one-shot` on a shared or
34
- untrusted computer so no session is read or written.
35
-
36
- ## Timetable and ICS privacy
37
-
38
- Raw JWXT responses are processed in memory. Only timetable fields needed for
39
- normalization are passed to `@nbtca/nbtcal`; the student profile object is
40
- discarded. Generated ICS files can reveal a person's location and routine, so
41
- they are created with mode `0600` where supported and should not be uploaded to
42
- public or "secret-link" hosting.
43
-
44
- ## Reporting vulnerabilities
45
-
46
- Use the repository's GitHub Security Advisory page. Do not include credentials,
47
- cookies, raw school responses or a personal ICS in a public issue.
3
+ Prompt sends credentials only to the allowlisted campus WebVPN and CAS login
4
+ flow. Passwords stay in memory, are masked during entry and are never accepted
5
+ through flags, environment variables or files. Interactive browser challenges
6
+ fail closed.
7
+
8
+ An optional persisted CookieJar is a bearer secret. Prompt writes it atomically
9
+ to the user's state directory with `0700` directory and `0600` file permissions
10
+ on POSIX systems. Use `nbtca schedule logout` to remove it, or `--one-shot` to
11
+ avoid reading or writing a session.
12
+
13
+ Exported calendars may reveal a person's routine and location. Keep session and
14
+ ICS files private.
15
+
16
+ Report vulnerabilities through GitHub Security Advisories. Never include
17
+ credentials, cookies, raw campus responses or personal calendars in public
18
+ issues.
package/dist/app/app.js CHANGED
@@ -1,24 +1,14 @@
1
1
  import { ansi, ensureCursorRestored } from '../core/canvas.js';
2
- import { composeFrame, computeBodyRows } from './frame.js';
3
- import { routeGlobalKey } from './keys.js';
4
- import { renderHeader, renderFooter, HEADER_LINES, FOOTER_LINES } from './chrome.js';
2
+ import { composeFrameLines, computeBodyRows, diffFrame } from './frame.js';
3
+ import { isPrintableKey, KeyStreamDecoder, routeGlobalKey } from './keys.js';
4
+ import { renderHeader, renderFooter, resolveChromeLayout } from './chrome.js';
5
5
  import { homeView } from './views/home.js';
6
6
  import { scheduleView } from './views/schedule.js';
7
7
  import { docsView } from './views/docs.js';
8
8
  import { eventsView } from './views/events.js';
9
9
  import { settingsView } from './views/settings.js';
10
10
  import { getAppTabs } from './tabs.js';
11
- /**
12
- * Event-driven full-screen app loop. Owns the alt-screen + raw-mode lifecycle
13
- * and composes every tab as a native `View` rendered in place. `ctx.runClassic`
14
- * remains as a scoped escape hatch a view can call itself when a single action
15
- * genuinely needs the real terminal (e.g. Docs handing off to glow/less to
16
- * read a file) — the app loop no longer dispatches whole tabs through it.
17
- *
18
- * Resolves once the user quits (q / Ctrl+C / Esc from home). Terminal state
19
- * (alt-screen, raw mode, cursor) is always restored before this resolves,
20
- * on SIGINT, on process exit, and even if an unexpected error is thrown.
21
- */
11
+ import { SPINNER_FRAME_MS } from '../core/components/spinner.js';
22
12
  export async function runApp() {
23
13
  if (!process.stdin.isTTY || !process.stdout.isTTY)
24
14
  return;
@@ -26,8 +16,14 @@ export async function runApp() {
26
16
  let scroll = 0;
27
17
  let running = true;
28
18
  let suspended = false;
19
+ let entered = false;
20
+ const lifecycle = new AbortController();
21
+ const keyDecoder = new KeyStreamDecoder();
22
+ let keyFlushTimer;
23
+ let clockTimer;
24
+ let busyTimer;
25
+ let painted;
29
26
  const viewIds = getAppTabs().map((tab) => tab.id);
30
- // Every tab is a native View rendered in place inside the alt-screen frame.
31
27
  const nativeViews = {
32
28
  home: homeView,
33
29
  schedule: scheduleView,
@@ -35,16 +31,42 @@ export async function runApp() {
35
31
  events: eventsView,
36
32
  settings: settingsView,
37
33
  };
34
+ const pendingLoads = new Map();
35
+ function loadView(id) {
36
+ const target = nativeViews[id];
37
+ if (!target?.load || pendingLoads.has(id))
38
+ return;
39
+ const pending = target
40
+ .load(ctx)
41
+ .catch(() => undefined)
42
+ .finally(() => pendingLoads.delete(id));
43
+ pendingLoads.set(id, pending);
44
+ }
38
45
  function size() {
39
46
  return { rows: process.stdout.rows || 24, cols: process.stdout.columns || 80 };
40
47
  }
41
48
  const ctx = {
42
- get size() { return size(); },
43
- get bodyRows() { return computeBodyRows(size().rows, HEADER_LINES, FOOTER_LINES); },
44
- rerender() { render(); },
45
- resetScroll() { scroll = 0; },
46
- runClassic(fn) { return runClassic(fn); },
47
- quit() { quit(); },
49
+ signal: lifecycle.signal,
50
+ get size() {
51
+ return size();
52
+ },
53
+ get bodyRows() {
54
+ const { rows } = size();
55
+ const chrome = resolveChromeLayout(rows);
56
+ return computeBodyRows(rows, chrome.headerLines, chrome.footerLines);
57
+ },
58
+ rerender() {
59
+ render();
60
+ },
61
+ resetScroll() {
62
+ scroll = 0;
63
+ },
64
+ runClassic(fn) {
65
+ return runClassic(fn);
66
+ },
67
+ quit() {
68
+ quit();
69
+ },
48
70
  };
49
71
  function render() {
50
72
  if (suspended || !running)
@@ -52,24 +74,38 @@ export async function runApp() {
52
74
  const { rows, cols } = size();
53
75
  const active = nativeViews[view];
54
76
  const tabs = getAppTabs();
55
- const header = renderHeader(tabs, view, cols);
56
- const footer = renderFooter(view, cols, tabs.length, active?.footerHint?.(tabs.length, cols));
77
+ const chrome = resolveChromeLayout(rows);
78
+ const header = renderHeader(tabs, view, cols, chrome.headerLines);
79
+ const footer = renderFooter(view, cols, tabs.length, active?.footerHint?.(tabs.length, cols), chrome.footerLines);
57
80
  const body = active?.render(ctx) ?? [];
58
- process.stdout.write(ansi.home + composeFrame(header, body, footer, rows, cols, scroll) + ansi.eraseDown);
81
+ const bodyScroll = active?.capturesInput?.() ? Number.MAX_SAFE_INTEGER : scroll;
82
+ const lines = composeFrameLines(header, body, footer, rows, cols, bodyScroll);
83
+ const patch = diffFrame(painted?.cols === cols ? painted.lines : undefined, lines);
84
+ painted = { cols, lines };
85
+ if (patch)
86
+ process.stdout.write(patch);
87
+ scheduleBusyTick(active?.isBusy?.() === true);
59
88
  }
60
- function onKey(data) {
61
- const key = data.toString();
89
+ function scheduleBusyTick(busy) {
90
+ if (busy && running && busyTimer === undefined) {
91
+ busyTimer = setTimeout(() => {
92
+ busyTimer = undefined;
93
+ render();
94
+ }, SPINNER_FRAME_MS);
95
+ return;
96
+ }
97
+ if (busy || busyTimer === undefined)
98
+ return;
99
+ clearTimeout(busyTimer);
100
+ busyTimer = undefined;
101
+ }
102
+ function dispatchKey(key) {
62
103
  if (key === '\x03') {
104
+ process.exitCode = 130;
63
105
  quit();
64
106
  return;
65
107
  } // Ctrl-C always quits, even mid-capture.
66
108
  const active = nativeViews[view];
67
- // Esc always reaches global routing, even while a view "captures" input
68
- // for a focused field (login/search text entry). Without this carve-out,
69
- // a view whose own Esc-handling doesn't escape its captured mode would
70
- // trap the user on that tab with no way out except Ctrl-C (quitting the
71
- // whole app). Esc must never be swallowed silently — it's the universal
72
- // way out of anything.
73
109
  if (active?.capturesInput?.() && key !== '\x1b') {
74
110
  active.handleKey?.(key, ctx);
75
111
  render();
@@ -81,17 +117,13 @@ export async function runApp() {
81
117
  return;
82
118
  }
83
119
  if (g.back) {
84
- // Esc steps back one level within the view first (e.g. its week grid
85
- // back to its own hub) — only once the view has nowhere left to step
86
- // back to does Esc leave the tab for Home. Matches how k9s/lazygit
87
- // treat Esc: back one level, not straight to the root.
88
120
  if (active?.handleBack?.(ctx)) {
89
121
  scroll = 0; // the new sub-view's content height has nothing to do with the old one's
90
122
  render();
91
123
  return;
92
124
  }
93
125
  view = 'home';
94
- void nativeViews['home']?.load?.(ctx)?.catch(() => { });
126
+ loadView('home');
95
127
  render();
96
128
  return;
97
129
  }
@@ -100,9 +132,12 @@ export async function runApp() {
100
132
  return;
101
133
  }
102
134
  if (g.scrollBy) {
103
- // fitBody (frame.ts) clamps this to [0, content.length - bodyRows]
104
- // on every render regardless of what's requested here, so this never
105
- // needs to know the current body's height to stay in bounds.
135
+ if (active?.capturesPageKeys?.()) {
136
+ active.handleKey?.(key, ctx);
137
+ scroll = 0;
138
+ render();
139
+ return;
140
+ }
106
141
  const page = Math.max(1, ctx.bodyRows - 2);
107
142
  scroll = Math.max(0, scroll + g.scrollBy * page);
108
143
  render();
@@ -111,35 +146,85 @@ export async function runApp() {
111
146
  active?.handleKey?.(key, ctx);
112
147
  render();
113
148
  }
149
+ function dispatchKeys(keys) {
150
+ for (let index = 0; index < keys.length && running && !suspended; index += 1) {
151
+ let key = keys[index];
152
+ if (key === undefined)
153
+ continue;
154
+ if (nativeViews[view]?.capturesInput?.() && isPrintableKey(key)) {
155
+ while (index + 1 < keys.length) {
156
+ const next = keys[index + 1];
157
+ if (next === undefined || !isPrintableKey(next))
158
+ break;
159
+ key += next;
160
+ index += 1;
161
+ }
162
+ }
163
+ dispatchKey(key);
164
+ }
165
+ }
166
+ function scheduleClock() {
167
+ if (!running)
168
+ return;
169
+ clockTimer = setTimeout(() => {
170
+ clockTimer = undefined;
171
+ render();
172
+ scheduleClock();
173
+ }, 60_000 - (Date.now() % 60_000));
174
+ }
175
+ function clearKeyFlush() {
176
+ if (keyFlushTimer === undefined)
177
+ return;
178
+ clearTimeout(keyFlushTimer);
179
+ keyFlushTimer = undefined;
180
+ }
181
+ function onKey(data) {
182
+ clearKeyFlush();
183
+ dispatchKeys(keyDecoder.write(data));
184
+ if (!running || suspended || !keyDecoder.hasPending)
185
+ return;
186
+ keyFlushTimer = setTimeout(() => {
187
+ keyFlushTimer = undefined;
188
+ dispatchKeys(keyDecoder.flush());
189
+ }, 20);
190
+ }
114
191
  function enter() {
192
+ if (entered || lifecycle.signal.aborted)
193
+ return;
194
+ entered = true;
195
+ painted = undefined;
196
+ keyDecoder.reset();
115
197
  ensureCursorRestored();
116
- process.stdout.write(ansi.enterAlt + ansi.hideCursor);
117
- if (process.stdin.isTTY)
118
- process.stdin.setRawMode(true);
119
- process.stdin.resume();
120
- process.stdin.on('data', onKey);
198
+ try {
199
+ process.stdout.write(ansi.enterAlt + ansi.hideCursor);
200
+ if (process.stdin.isTTY)
201
+ process.stdin.setRawMode(true);
202
+ process.stdin.resume();
203
+ process.stdin.on('data', onKey);
204
+ }
205
+ catch (error) {
206
+ leave();
207
+ throw error;
208
+ }
121
209
  }
122
210
  function leave() {
211
+ if (!entered)
212
+ return;
213
+ entered = false;
214
+ clearKeyFlush();
215
+ keyDecoder.reset();
123
216
  process.stdin.removeListener('data', onKey);
124
217
  if (process.stdin.isTTY)
125
218
  process.stdin.setRawMode(false);
126
219
  process.stdout.write(ansi.showCursor + ansi.leaveAlt);
127
- // `enter()` calls `stdin.resume()`; a resumed stdin stream keeps the
128
- // Node event loop alive by design even with no listeners attached. The
129
- // classic bridge calls `enter()` again right after, so pausing here is
130
- // always safe — either it's about to be resumed, or the app is quitting
131
- // for good and this is what lets the process actually exit.
132
220
  process.stdin.pause();
133
221
  }
134
222
  function switchTo(id) {
135
223
  scroll = 0;
136
224
  view = id;
137
- void nativeViews[id]?.load?.(ctx)?.catch(() => { });
225
+ loadView(id);
138
226
  render();
139
227
  }
140
- // A classic surface (currently only Docs' glow/less pager) owns its own
141
- // raw-mode + rendering, so the app must fully leave() the alt-screen
142
- // before invoking it and re-enter() after it returns.
143
228
  async function runClassic(fn) {
144
229
  suspended = true;
145
230
  leave();
@@ -147,11 +232,10 @@ export async function runApp() {
147
232
  await fn();
148
233
  }
149
234
  catch (err) {
150
- // Classic surfaces are expected to surface their own errors, but if
151
- // one throws anyway, don't swallow it silently: leave() has already
152
- // restored cooked mode, so writing to stderr here is visible.
153
235
  process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
154
236
  }
237
+ if (!running)
238
+ return;
155
239
  enter();
156
240
  suspended = false;
157
241
  render();
@@ -163,10 +247,18 @@ export async function runApp() {
163
247
  leave();
164
248
  }
165
249
  function onSigint() {
250
+ process.exitCode = 130;
251
+ quit();
252
+ }
253
+ function onSigterm() {
254
+ process.exitCode = 143;
166
255
  quit();
167
- process.exit(0);
168
256
  }
169
- let resolveRun = () => { };
257
+ function onSighup() {
258
+ process.exitCode = 129;
259
+ quit();
260
+ }
261
+ let resolveRun;
170
262
  const done = new Promise((resolve) => {
171
263
  resolveRun = resolve;
172
264
  });
@@ -174,6 +266,12 @@ export async function runApp() {
174
266
  if (!running)
175
267
  return;
176
268
  running = false;
269
+ lifecycle.abort();
270
+ if (clockTimer !== undefined) {
271
+ clearTimeout(clockTimer);
272
+ clockTimer = undefined;
273
+ }
274
+ scheduleBusyTick(false);
177
275
  try {
178
276
  leave();
179
277
  }
@@ -181,22 +279,27 @@ export async function runApp() {
181
279
  process.stdout.removeListener('resize', onResize);
182
280
  process.removeListener('exit', onExit);
183
281
  process.removeListener('SIGINT', onSigint);
282
+ process.removeListener('SIGTERM', onSigterm);
283
+ process.removeListener('SIGHUP', onSighup);
184
284
  resolveRun();
185
285
  }
186
286
  }
187
287
  process.on('exit', onExit);
188
288
  process.stdout.on('resize', onResize);
189
289
  process.once('SIGINT', onSigint);
290
+ process.once('SIGTERM', onSigterm);
291
+ process.once('SIGHUP', onSighup);
190
292
  try {
191
293
  enter();
192
- void nativeViews['home']?.load?.(ctx)?.catch(() => { });
294
+ loadView('home');
193
295
  render();
296
+ scheduleClock();
194
297
  await done;
195
298
  }
196
299
  finally {
197
- // Safety net: if we got here via an unexpected throw rather than quit(),
198
- // make sure the terminal is restored and listeners don't leak.
199
- if (running)
200
- quit();
300
+ quit();
301
+ await Promise.allSettled(Object.values(nativeViews).map(async (target) => {
302
+ await target.dispose?.();
303
+ }));
201
304
  }
202
305
  }